reveal.js styling¶

In [1]:
%%html
<style type="text/css">

.reveal div.highlight {
    margin: 0; 
}

.reveal div.highlight>pre {
    margin: 0; 
    width: 100%;
    font-size: 15px;
}

.reveal div.jp-OutputArea-output>pre {
    margin: 0; 
    width: 75%;
    font-size: var(--jp-code-font-size);
    box-shadow: none;
}

</style>

Data Visualization¶

Import der Python-Packages¶

In [2]:
import pandas as pd
import numpy as np
import pytz

from bokeh.plotting import figure, output_file, output_notebook, show
from bokeh.models import ColumnDataSource, CDSView, Legend 
from bokeh.models import CustomJS, Slider, OpenURL, TapTool, CustomJSFilter
from bokeh.models import DatetimeTickFormatter
from bokeh.models.tools import HoverTool, BoxZoomTool, ResetTool, PanTool
from bokeh.layouts import column, row 
from bokeh.io import show 

output_notebook()
Loading BokehJS ...

Einlesen der Daten in einen Dataframe¶

In [3]:
df = pd.read_csv('../data/220124-wutbuerger-preprocessed.csv', 
                 parse_dates=['date'], encoding='utf8')
#df = pd.read_pickle('../data/220125-wutbuerger-preprocessed.pickle')

Dataframe inspizieren¶

In [4]:
df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 9149 entries, 0 to 9148
Data columns (total 11 columns):
 #   Column                   Non-Null Count  Dtype              
---  ------                   --------------  -----              
 0   id                       9149 non-null   int64              
 1   date                     9149 non-null   datetime64[ns, UTC]
 2   tweet                    9149 non-null   object             
 3   hashtags                 9149 non-null   object             
 4   username                 9149 non-null   object             
 5   link                     9149 non-null   object             
 6   nretweets                9149 non-null   int64              
 7   nlikes                   9149 non-null   int64              
 8   nreplies                 9149 non-null   int64              
 9   nqoutes                  0 non-null      float64            
 10  char_per_url_free_tweet  9149 non-null   int64              
dtypes: datetime64[ns, UTC](1), float64(1), int64(5), object(4)
memory usage: 786.4+ KB

Feature Engineering und Erstellen neuer Spalten¶

In [5]:
# convert time zone: funktioniert nicht wie erwartet >  Berlin is UTC + 1 (Summertime +2)
# bei allen Datumsangaben 1 Stunde dazu addiert
# df.loc[:, 'date'] = df.loc[:, 'date'].dt.tz_convert(pytz.timezone('Europe/Berlin'))
df.loc[:, 'date'] = df.loc[:, 'date'] + pd.Timedelta(hours=1)

# hours_minutes stellt die x-Achse dar
df.loc[:, 'hours_minutes'] = ((df.loc[:, 'date'].dt.hour * 60) \
                              + df.loc[:, 'date'].dt.minute) / 60

# tweet_score zeigt die Größe der Marker an
df.loc[:, 'tweet_score'] = 5 + ((df.loc[:, 'nlikes']) \
                             + (df.loc[:, 'nretweets'] * 2) \
                             + (df.loc[:, 'nreplies'] * 3)) / 100

df = df.drop(['nqoutes'], axis=1)

Helferfunktion¶

In [6]:
# Die Funktion setzt in jeden Tweets an jeder 8. Indexstelle den html-Tag <br>
# Damit wird bei der Hoverbox ein Zeilenumbruch erzeugt, um den Text lesbar zu machen.

def insert_br(text):
    '''
    function inserts <br> every 8th word
    to create custom tooltip with html
    '''      
    text = text.split(' ')
    for i in range(len(text) // 8):    
        text.insert((i+1) * 8, '<br>')
    
    return ' '.join(text)

Erstellen einer neuen Spalte¶

In [7]:
df.loc[:, 'tweet_br'] = df.loc[:, 'tweet'].apply(lambda x: insert_br(x))

Erstellen der Diskurse: Stuttgart21¶

In [8]:
# Hier werden die Diskurse mit Hilfe von Tuples aus 3 Elementen erstellt:
# Index 0: Label des Diskurses
# Index 1: Farbe des Diskurses
# Index 2: Liste von Schlüsselworten, die zu dem Diskurs gehören

s21_diskurs = ('S21', 'red',  ['s21', 
                               'stuttgart', 
                               'stuttgart21', 
                               'stuttgarter', 
                               'brandschutz'])

Erstellen der Diskurse: Afd und Pegida¶

In [9]:
afd_diskurs = ('AfD / Pegida', 'green', ['afd', 
                                         'pegida', 
                                         'noafd', 
                                         'nopegida', 
                                         'nazis', 
                                         'nazi', 
                                         'nonazis', 
                                         'lügenpresse', 
                                         'fckafd'])

Erstellen der Diskurse: Corona¶

In [10]:
corona_diskurs = ('Corona', 'orange', ['coronaleugner', 
                                       'covidioten', 
                                       'covidiot', 
                                       'corona', 
                                       'covid19', 
                                       'coronavirus', 
                                       'coronademo', 
                                       'infektion', 
                                       'maske', 
                                       'maskenverweigerer', 
                                       'cov19unvereinbar', 
                                       'lockdown', 
                                       'impfgegner', 
                                       'maskenpflicht', 
                                       'drosten', 
                                       'pandemie', 
                                       'virus', 
                                       'coronakrise'])

Erstellen der Diskurse: Mutbürger, Hutbürger, Querdenker¶

In [11]:
mutbürger_diskurs = ('Mutbürger', 'darkturquoise', ['mutbürger'])

hutbürger_diskurs = ('Hutbürger', 'greenyellow', ['hutbürger'])

querdenker_diskurs = ('Querdenker', 'hotpink', ['querdenker', 
                                                'querdenken', 
                                                'verschwörungstheoretiker']) 
# verschwörungstheoretiker ist vielleicht eigener Diskurs

Erstellen einer Diskursliste¶

In [12]:
diskurs_liste = [s21_diskurs, 
                 afd_diskurs, 
                 corona_diskurs, 
                 mutbürger_diskurs, 
                 hutbürger_diskurs, 
                 querdenker_diskurs, 
                 ('keine Zuordnung', '#1da1f2') ]

Helferfunktion¶

In [13]:
# Die Funktion erstellt eine Spalte für den jeweiligen Diskurs
# Die Liste der Hashtags wird in der List-Comprehension geprüft, 
# ob darin Schlüsselwörter des entsprechenden Diskurses enhalten ist.
# Wenn das so ist, wird in der Spalte für den Diskurs das Label des Diskurses eingetragen, 
# falls nicht wird np.nan eingetragen
# Vorteil von jeweils einer eigenen Spalte pro Diskurs: 
# Überschneidungen von Diskursen können visuell erfasst werden

def diskurs_maker(hashtaglist, diskurstuple):
    '''
    checks if hashtaglist contains hastag that ist in list of Diskurs.
    '''
    if any(item in hashtaglist for item in diskurstuple[2]):
        return diskurstuple[0]       
    else:
        return np.nan

Erstellen der Diskurse¶

In [14]:
# Die for-Schleife erstellt die Spalten mit den Diskursen

for diskurs in diskurs_liste[:-1]:
    df.loc[:, diskurs[0]] = df.loc[:, 'hashtags'].apply(lambda x: diskurs_maker(x, diskurs))

Erstellen der Diskurse¶

In [15]:
# Die Lambda-Funkton prüft, ob es so viele NaNs in einer Reihe wie Diskurse gibt, 
# also prüft, ob ein Tweet zu keinem der Diskurse gepasst hat: 
# Dann wird 'keine Zuordnung' eingefügt
# Die Zahl der Diskurse wird mit len(Diskursliste - 1) errechnet
# Das letzte Element der Diskursliste ist das Tuple 
# für die Farbzuordnung der nicht zugeordneten Tweets

df.loc[:, 'keine Zuordnung'] = df.isna() \
                                 .sum(axis=1) \
                                 .apply(lambda x: 'keine Zuordnung' \
                                        if x == len(diskurs_liste) - 1 \
                                        else np.nan )

Erstellen der Diskurse¶

In [16]:
# Die for-Schleife erstellt auf Basis der Datenspalte die sources für die Bokeh-Figure
# Dazu wird für jede Diskursspalte mit einer Boolschen Maske geprüft, 
# ob das Diskurs-Label in der Spalte enthalten ist
# Dann wird mit ColumnDataSource der gefilterte Dataframe in source umgewandelt 
# und der source_list angefügt.

source_list = []

for diskurs in diskurs_liste:
    
    source = df.loc[df.loc[:, diskurs[0]] == diskurs[0], :]    
    source_list.append(ColumnDataSource(source))

Erstellen der Slider¶

In [17]:
# In dieser Zellen werden die Slider für Retweets, Likes, Replies und Textlänge erstellt
# Wichtig ist, das der javascript Code für jeden Slider, jede source für die Diskurse
# mit sourceX.change.emit() aktiviert, sonst passiert bei den Slidern nichts.
# Ebenso muss bei den Args alle Sources übergeben werden.
# >>> javascript code ist nötig, damit die html-Datei als Standalone funktioniert!
js_code = """
   source0.change.emit();
   source1.change.emit();
   source2.change.emit();
   source3.change.emit();
   source4.change.emit();
   source5.change.emit();
   source6.change.emit();
"""
source_dict = dict(source0=source_list[0], 
                     source1=source_list[1], 
                     source2=source_list[2],
                     source3=source_list[3],
                     source4=source_list[4],
                     source5=source_list[5],
                     source6=source_list[6])

Erstellen der Slider¶

In [18]:
# Die init_values sind die min bzw. max Werte der jeweiligen Datenspalte; 
# diese werden für die 'Begrenzung' des Slider-Raumes genutzt und 
# für den Start-Value des Sliders genutzt.

# Slider für Anzeige der Retweets
init_value_rt = (df.loc[:, 'nretweets'].min(), df.loc[:,'nretweets'].max())
rt_slider = Slider(start=init_value_rt[0], value=0, end=100, step=1, 
                   title='Anzahl der Retweets (zw. 0 und 100 einstellbar)')
rt_slider.js_on_change('value', CustomJS(args=source_dict, code=js_code))

# Slider für Anzeige der Likes
init_value_li = (df.loc[:, 'nlikes'].min(), df.loc[:,'nlikes'].max())
li_slider = Slider(start=init_value_li[0], value=0, end=100, step=1, 
                   title='Anzahl der Likes (zw. 0 und 100 einstellbar)')
li_slider.js_on_change('value', CustomJS(args=source_dict, code=js_code))

# Slider für Anzeige der Replies
init_value_li = (df.loc[:, 'nreplies'].min(), df.loc[:,'nreplies'].max())
re_slider = Slider(start=init_value_li[0], value=0, end=100, step=1, 
                   title='Anzahl der Antworten (zw. 0 und 100 einstellbar)')
re_slider.js_on_change('value', CustomJS(args=source_dict, code=js_code))

# Slider für Anzeige der Tweetlänge
init_value_tl = (df.loc[:, 'char_per_url_free_tweet'].min(), df.loc[:,'char_per_url_free_tweet'].max())
tl_slider = Slider(start=init_value_tl[0], value=0, end=init_value_tl[1], step=1, 
                   title='Länge der Tweets')
tl_slider.js_on_change('value', CustomJS(args=source_dict, code=js_code))

Erstellen der Figure¶

In [19]:
# Zunächst wird die figure mit den Maßen, Größenanpassung 
# und Toolbar instantiiert.

p = figure(height=875, 
           width=875,
           sizing_mode="stretch_both", # vergrößert die figure auf die Breite des Browsers
           toolbar_location="above", 
           tools= ['pan', 'wheel_zoom', 'box_zoom', 'save', 'reset', 'tap'])

view_list = []
custom_filter_list = []

Erstellen der Figure¶

In [20]:
# Für jede source wird ein Customfilter angelegt
# Im javascript code werden alle Slider-Einstellungen verarbeitet
# Nur die Indices, die zu den Slider-Einstellungen passen, werden zurückgegeben
# Der Customfilter wird der custom_filter_list beigefügt

for source in source_list:
    custom_filter = CustomJSFilter(args=dict(rt_slider=rt_slider, 
                                             li_slider=li_slider, 
                                             re_slider=re_slider, 
                                             tl_slider=tl_slider), code='''
        var indices = [];
        for (var i = 0; i < source.get_length(); i++){
            if (source.data['nretweets'][i] >= rt_slider.value && source.data['nlikes'][i] >= li_slider.value && source.data['nreplies'][i] >= re_slider.value && source.data['char_per_url_free_tweet'][i] >= tl_slider.value){
                indices.push(true);
            } else {
                indices.push(false);}}
        return indices; 
        ''')
    
    custom_filter_list.append(custom_filter)

Erstellen der Figure¶

In [21]:
# Für jeden Source wird eine View erstellt
# Bei den filters wird die gesamte custom_filter_list übergeben

for source in source_list:
    
    
    view = CDSView(source=source, filters=custom_filter_list)  
    view_list.append(view)    
    
# aus den Listen werden die Grafiken erstellt und in eine figure gepackt
for source, diskurs, view in zip(source_list, diskurs_liste, view_list):
    
    p.circle(x='hours_minutes', y='date', color=diskurs[1], fill_alpha=0.5, 
             size='tweet_score', legend_label=diskurs[0], 
             source=source, view=view) 

Erstellen Taptool¶

In [22]:
# Taptool: Durch einen Klick auf den Marker wird der Link zum Tweet 
# aus der link-Spalte abgerufen:
# In einem neuen Browser Fenster öffnet sich der Tweet

taptool = p.select(type=TapTool)
taptool.callback = OpenURL(url='@link')

Erstellen Hovertool¶

In [23]:
hover = HoverTool(tooltips=[('Datum', '@date{%F %H:%M}'), 
                            ('Tweet', """<span style='font-size: 17px; 
                                                      font-weight: bold; 
                                                      width=42'
                                                      >@tweet_br{safe}</span>"""),
                            ('von', '@username'),
                            ('Retweets', '@nretweets'),
                            ('Likes', '@nlikes'),
                            ('Replies', '@nreplies'),
                            ('Tweet-Score', '@tweet_score'),
                            ], 
                  formatters={'@date': 'datetime'})
p.add_tools(hover)

Layout Styling der Figure¶

In [24]:
p.y_range.flipped = True
p.yaxis.formatter=DatetimeTickFormatter()
p.legend.location = 'top_left'
p.legend.click_policy='hide'
p.legend.title = 'Diskurse\n (click to hide)'
p.legend.title_text_font_style = 'normal'
p.title.text = 'Wutbürger Tweets'
p.xaxis.axis_label = 'Uhrzeit'
p.yaxis.axis_label = 'Datum'
p.add_layout(p.legend[0], 'left')
p.xgrid.grid_line_color = None
p.ygrid.band_fill_alpha = 0.1
p.ygrid.band_fill_color = "grey"

Ausgabe als Standalone HTML-Datei¶

In [25]:
# output to standalone HTML file
output_file('220126-data-visualization-wutbuerger-interaktiv.html')

Anordnung des Layouts von Slidern und Figure¶

In [26]:
layout = row(column(rt_slider, li_slider, re_slider, tl_slider), p)
show(layout)

#Wutbürger interaktive Visualisierung¶